/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.    
 */
package dk.redillusions.jdbc.proxy;

import java.lang.reflect.Proxy;
import java.sql.Connection;
import java.sql.Driver;
import java.sql.DriverManager;
import java.sql.DriverPropertyInfo;
import java.sql.SQLException;
import java.util.Properties;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import dk.redillusions.jdbc.proxy.statistics.StatisticsService;

/**
 * <p>
 * Implementation of the {@link Driver} interface which basically proxies calls
 * to an underlying implementation (such as the mysql driver).
 * </p>
 * <p>
 * In order to use the driver, the connect url passed to the driver must include
 * the <code>{@value #DRIVER_CLASS_PROPERTY}</code> property with the class of
 * the driver which should be the underlying driver. The driver does not remove
 * the <code>{@value #DRIVER_CLASS_PROPERTY}</code> from the connect url before
 * passing it to the underlying driver. The connect URL (as passed to
 * {@link #connect(String, Properties)} and {@link #acceptsURL(String)}) must
 * start with {@value #JDBC_PREFIX}. The rest of the URL should be the same as
 * if not using the jdbc-proxy.
 * </p>
 * 
 * @author Sune Wettersteen
 * 
 */
public class ParameterizedProxyDriver implements Driver {
    /**
     * The part of the {@link #JDBC_PREFIX} which identifies the
     * {@link ParameterizedProxyDriver}.
     */
    private static final String PROX_PREFIX = "proxpar:";

    /**
     * The prefix for the {@link ParameterizedProxyDriver} connection.
     */
    public static final String JDBC_PREFIX = "jdbc:" + PROX_PREFIX;

    /**
     * The property name for the underlying driver class.
     */
    public static final String DRIVER_CLASS_PROPERTY = "actualdriver";

    static {
        try {
            DriverManager.registerDriver(new ParameterizedProxyDriver());
        } catch (SQLException e) {
            throw new RuntimeException("Failed to register driver", e);
        }
    }

    /**
     * The actual underlying driver.
     */
    private Driver underlyingDriver;

    /**
     * The {@link ParameterizedProxyDriver} accepts the given url if and only
     * if:
     * <ul>
     * <li>JDBC URL starts with {@link ParameterizedProxyDriver#JDBC_PREFIX}</li>
     * <li>All the correct settings have been added to the connect string</li>
     * <li>The underlying {@link Driver} accepts the parsed URL</li>
     * </ul>
     * See the settings for more on how to create a correct connect string.
     * 
     * @param url
     *            the URL of the database
     * @return <code>true</code> if this driver understands the given URL;
     *         <code>false</code> otherwise
     * @exception SQLException
     *                if a database access error occurs
     */
    @Override
    public final boolean acceptsURL(final String url) throws SQLException {
        final ProxyDriverURL parsedURL = ProxyDriverURL.fromURL(url);

        if (!parsedURL.isValid()) {
            return false;
        }

        this.ensureDriverAvailable(parsedURL);

        return this.underlyingDriver.acceptsURL(parsedURL.getDriverConnectURL());
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public final Connection connect(final String url, final Properties info) throws SQLException {
        final ProxyDriverURL parsedURL = ProxyDriverURL.fromURL(url);

        if (!parsedURL.isValid()) {
            throw new RuntimeException("Given URL is invalid: " + parsedURL);
        }

        this.ensureDriverAvailable(parsedURL);

        // Obtain the actual connection from the driver
        final Connection connection = this.underlyingDriver.connect(parsedURL.getDriverConnectURL(), info);
        // If the connection was null just return it
        if (connection == null) {
            return connection;
        }

        /*
         * Create the proxy connection which is sent to the connection
         * invocation handler
         */
        final ProxyConnection proxyConnection = new ProxyConnection(connection);

        // Create the invocation handler for the proxy
        final ConnectionInvocationHandler connectionInvocationHandler = new ConnectionInvocationHandler(proxyConnection);
        // Add statistics delegate
        connectionInvocationHandler.addConnection(StatisticsService.newConnectionStatisticsDelegate());

        // Create and return the connection proxy
        final Connection connectionProxy = (Connection) Proxy.newProxyInstance(this.getClass().getClassLoader(), new Class<?>[] { Connection.class }, connectionInvocationHandler);

        return connectionProxy;
    }

    /**
     * This will reflect the major version of the
     * {@link ParameterizedProxyDriver}, and not that of the underlying driver.
     * 
     * @return this driver's major version number
     */
    @Override
    public final int getMajorVersion() {
        return 1;
    }

    /**
     * This will reflect the minor version of the
     * {@link ParameterizedProxyDriver}, and not that of the underlying driver.
     * 
     * @return this driver's minor version number
     */
    @Override
    public final int getMinorVersion() {
        return 0;
    }

    /**
     * The possible properties are that of the {@link ParameterizedProxyDriver}
     * combined with that of the underlying driver. If the underlying driver is
     * not yet available, this method will only return the properties required
     * for the {@link ParameterizedProxyDriver}.
     * 
     * @param url
     *            the URL of the database to which to connect
     * @param info
     *            a proposed list of tag/value pairs that will be sent on
     *            connect open
     * @return an array of <code>DriverPropertyInfo</code> objects describing
     *         possible properties. This array may be an empty array if no
     *         properties are required.
     * @exception SQLException
     *                if a database access error occurs
     */
    @Override
    public final DriverPropertyInfo[] getPropertyInfo(final String url, final Properties info) throws SQLException {
        final ProxyDriverURL parsedURL = ProxyDriverURL.fromURL(url);
        this.ensureDriverAvailable(parsedURL);

        /*
         * Create the property and populate it with data from the passed
         * properties, if available.
         */
        final DriverPropertyInfo propertyInfo = new DriverPropertyInfo(DRIVER_CLASS_PROPERTY, info.getProperty(DRIVER_CLASS_PROPERTY));
        propertyInfo.required = true;
        propertyInfo.description = "This is the actual underlying driver";

        /*
         * If the underlying driver is null we just return the properties passed
         * to us
         */
        if (this.underlyingDriver == null) {
            return new DriverPropertyInfo[] { propertyInfo };
        }

        // Otherwise relay to the underlying driver
        final DriverPropertyInfo[] underlyingPropertyInfoArray = this.underlyingDriver.getPropertyInfo(url, info);

        /*
         * Create the final array of driver properties (1 (ours) + the
         * underlying drivers requirements).
         */
        final DriverPropertyInfo[] finalPropertyInfoArray = new DriverPropertyInfo[1 + underlyingPropertyInfoArray.length];

        // Fill the property info array
        finalPropertyInfoArray[0] = propertyInfo;
        System.arraycopy(underlyingPropertyInfoArray, 0, finalPropertyInfoArray, 1, underlyingPropertyInfoArray.length);

        return finalPropertyInfoArray;
    }

    /**
     * <p>
     * The {@link ParameterizedProxyDriver} is jdbc complient if, and only if,
     * the underlying driver is jdbc compliant. As such, this method will
     * reflect the {@link Driver#jdbcCompliant()} implementation of the
     * underlying driver.
     * </p>
     * <p>
     * NOTE: If the underlying driver is not yet available, this method will
     * return <code>false</code>.
     * </p>
     * 
     * @return <code>true</code> if this driver is JDBC Compliant;
     *         <code>false</code> otherwise
     */
    @Override
    public final boolean jdbcCompliant() {
        if (this.underlyingDriver != null) {
            return this.underlyingDriver.jdbcCompliant();
        }

        return false;
    }

    /**
     * Ensure that the local driver is available.
     * 
     * @param url
     *            The URL to ensure the driver is available for
     */
    @SuppressWarnings("unchecked")
    private void ensureDriverAvailable(final ProxyDriverURL url) {
        if (!url.isValid()) {
            throw new RuntimeException("Given URL is not valid: " + url.toString());
        }

        if (this.underlyingDriver == null) {
            final String driverClassName = url.getDriverClassName();
            try {
                Class<? extends Driver> clazz = (Class<? extends Driver>) Class.forName(driverClassName);
                this.underlyingDriver = clazz.newInstance();
            } catch (ClassNotFoundException e) {
                throw new RuntimeException("Failed to locate class for driver: " + driverClassName);
            } catch (InstantiationException e) {
                throw new RuntimeException("Failed to instantiate class: " + driverClassName);
            } catch (IllegalAccessException e) {
                throw new RuntimeException("Failed to instantiate class: " + driverClassName);
            }

        }
    }

    /**
     * URL and URL parser used for database connections.
     * 
     * @author Sune Wettersteen
     * 
     */
    public static final class ProxyDriverURL {
        /**
         * @see #isValid()
         */
        private boolean valid;

        /**
         * @see #getOriginalURL()
         */
        private String originalURL;

        /**
         * @see #getDriverConnectURL()
         */
        private String driverConnectURL;

        /**
         * @see #getDriverClassName()
         */
        private String driverClassName;

        /*
         * Driver property follow by any number of white spaces and at least one
         * = and any number of white spaces again. This is followed by any
         * sequence of normal characters and .s (. as in package names). This
         * should be a sufficient regular expression.
         * 
         * driverproperty[\s=]*([\.\w])*
         */
        /**
         * Pattern for identifying the driver class property in the connect url.
         */
        private static final Pattern DRIVER_CLASS_PATTERN = Pattern.compile(".*" + DRIVER_CLASS_PROPERTY + "[\\s]*=[\\s]*([\\.\\w]*).*");

        /**
         * Constructor.
         */
        private ProxyDriverURL() {
            // No instantiation
        }

        /**
         * <p>
         * Get the "real" jdbc database connect URL for the underlying
         * {@link Driver}.
         * </p>
         * <p>
         * NOTE: This is only guaranteed to return something meaningful if
         * {@link #isValid()} returns <code>true</code>.
         * </p>
         * 
         * @return The database connect url
         */
        public String getDriverConnectURL() {
            return this.driverConnectURL;
        }

        /**
         * Get the original URL (the URL this {@link ProxyDriverURL} was created
         * from).
         * 
         * @return The original URL
         */
        public String getOriginalURL() {
            return this.originalURL;
        }

        /**
         * <p>
         * Get the class name for the underlying {@link Driver}.
         * </p>
         * <p>
         * NOTE: This is only guaranteed to return something meaningful if
         * {@link #isValid()} returns <code>true</code>.
         * </p>
         * 
         * @return The class name of the {@link Driver}
         */
        public String getDriverClassName() {
            return this.driverClassName;
        }

        /**
         * <p>
         * Whether or not the URL is valid.
         * </p>
         * <p>
         * NOTE: This only evaluates whether or not this is a valid
         * {@link ParameterizedProxyDriver}, not whether or not it is a valid
         * URL for the underlying {@link java.sql.Driver}.
         * </p>
         * 
         * @return <code>true</code> if the URL is a valid
         *         {@link ParameterizedProxyDriver} URL, otherwise
         *         <code>false</code>
         */
        public boolean isValid() {
            return this.valid;
        }

        /**
         * A database URL such as that passed to
         * {@link java.sql.Driver#acceptsURL(String)} and
         * {@link java.sql.Driver#connect(String, java.util.Properties)}.
         * 
         * @param databaseURL
         *            The database URL
         * @return the parsed database URL as a {@link ProxyDriverURL} instance
         */
        public static ProxyDriverURL fromURL(final String databaseURL) {
            final ProxyDriverURL url = new ProxyDriverURL();

            // We default to true
            url.valid = true;

            // Set the original URL
            url.originalURL = databaseURL;

            // Required prefix
            final String prefix = ParameterizedProxyDriver.JDBC_PREFIX;

            // Check if the required prefix is available
            if (!databaseURL.startsWith(prefix)) {
                url.valid = false;
            }

            // Remove the prefix from the url
            final String urlNoPrefix = databaseURL.replace(ParameterizedProxyDriver.PROX_PREFIX, "");

            // Match the driver class pattern against the connect url
            final Matcher matcher = DRIVER_CLASS_PATTERN.matcher(urlNoPrefix);

            // Get the driver class name
            String driverClassName = null;
            if (matcher.matches()) {
                driverClassName = matcher.group(1);
            } else {
                url.valid = false;
            }

            // Set the driver class name. Its okay for this to be null.
            url.driverClassName = driverClassName;

            // Set the driver connect url.
            url.driverConnectURL = urlNoPrefix;

            return url;
        }

        /**
         * {@inheritDoc}
         */
        @Override
        public String toString() {
            StringBuilder builder = new StringBuilder();
            builder.append("ProxURL [driverClassName=");
            builder.append(this.driverClassName);
            builder.append(", driverConnectURL=");
            builder.append(this.driverConnectURL);
            builder.append(", originalURL=");
            builder.append(this.originalURL);
            builder.append(", valid=");
            builder.append(this.valid);
            builder.append("]");
            return builder.toString();
        }

    }
}
